Skip to content

feat: canvas harness#81

Merged
winlp4ever merged 63 commits into
mainfrom
feat/canvas-harness
May 25, 2026
Merged

feat: canvas harness#81
winlp4ever merged 63 commits into
mainfrom
feat/canvas-harness

Conversation

@winlp4ever
Copy link
Copy Markdown
Contributor

No description provided.

winlp4ever added 30 commits May 22, 2026 17:13
prepare the feat/canvas-harness branch with @canvas-harness/core
and @canvas-harness/react. no code changes yet — react-flow stays
the active canvas backend until phase 7 of the migration plan.
scaffold webui/src/features/board/harness/convert/ with explicit
note ↔ node and link ↔ edge converters covering all 18 node types
and all 4 EdgeEnd variants (attached center / attached offset /
free source / free target). round-trip preserves identity fields
(version, graphUid, parentId, roughSeed), extra properties
(emoji, pinned, slideName), and edge control point + arrowheads
+ pathStyle. degrees ↔ radians and 0-100 ↔ 0-1 opacity converted
at the boundary; fillStyle and underline/strikethrough dropped
per migration plan §3.3.

vitest + jsdom installed for the round-trip suite (30 cases).
no app code wired to the converters yet — that lands in phase 2.
note.content.markdown is the body (inline text on a shape — see
note-card.tsx:94). note.label is the title (sheet header, folder
name, breadcrumbs, list cards). canvas-harness node.content maps
to the body, not the title.

- node.content ↔ note.content.markdown
- data.label ↔ note.label (preserved untouched for round-trip)
- legacy fallback retained: when note.content is absent but
  note.label is present, treat label as the text — matches
  note-card.tsx's `note.content?.markdown || note.label?.markdown`
four small builders + a memoized hook that fan the current Dim0
theme out to canvas-harness's customization surfaces:

- resolver.ts        — ThemeResolver for the 5 fallback tokens
                       (strokeColor / backgroundColor / textColor /
                       edge.strokeColor / edge.label.background),
                       derived from the theme swatch trio
                       (bg, primary, accent)
- selection-color.ts — selection chrome color (outline / handles /
                       marquee / draft edges); single accent across
                       all themes for v1, OVERRIDES map ready for
                       per-theme tuning
- minimap-colors.ts  — Minimap viewportColor (matches selection) +
                       backgroundColor / borderColor / defaultNodeColor
                       from the swatch
- background.ts      — CanvasBackground (color + pattern=none for now;
                       paper-texture pattern is §12 open question)
- use-board-theme.ts — composes the four via useTheme(); memoized so
                       the renderer isn't recreated each render

all six themes (parchment / catppuccin / tokyo-night / gruvbox /
monokai-pro / rose-pine) × light/dark covered. nothing wired to a
canvas yet — phase 4 picks this up.
phase 2: scaffolds the two stores and the snapshot+diff persistence
loop. no UI wired — verified via unit tests + tsc.

store/
  create-board-store.ts   thin createCanvasStore({ nodeTypes }) factory.
                          nodeTypes empty for now; phase 3 supplies them.
  board-app-store.ts      zustand store for app-level state that doesn't
                          belong in the canvas store: boardId / rootId,
                          boardLabel / visibility / canEdit / isLoading,
                          viewSlides / presentationMode, folder depth,
                          background (+ texture), activeNodeSurface.
                          setBoardScope resets per-board state on change.

persist/
  diff-snapshots.ts       Snapshot + ApiCall types + diffSnapshots(prev, next).
                          json-string compare on Node/Edge; emits
                          removeLink → removeNote → addNote → addLink →
                          updateNote → updateLink for referential safety.
  flush-api-calls.ts      Maps ApiCall[] onto the existing api/ helpers
                          (addNotes / updateNote / removeNote / addLinks /
                          updateLink / removeLink). Parallel within
                          phases, bulk POST for adds.
  snapshot-load.ts        hydrateBoardStore: GET /boards/:id, convert
                          notes/links to nodes/edges, applyBatch + clear
                          history. first-load is never undoable.
  use-debounced-save.ts   subscribe('change') → debounce 500ms → snapshot
                          → diff → flush. undo/redo participate for free
                          (history-origin commits re-emit 'change'; the
                          post-undo scene is what gets snapshotted next
                          flush). returns SaveStatus pill state.

tests: 7 new in diff-snapshots.test.ts (no-op / add / remove / update /
ordering / edge-before-node remove / node-before-edge add). total 38.
phase 3.0 — pre-fix that was missing from the convert layer.

four built-ins use different names than Dim0:
  rectangle         → rect
  layered-rectangle → layered-rect
  layered-circle    → layered-ellipse
  slide             → frame

note.style.type was being passed straight through, so a converted
"rectangle" node hit the lib with type "rectangle" — unknown to the
built-in paint dispatch. tests passed because we never rendered.

- harness/convert/node-type.ts  bidirectional map
- note-to-node + node-to-note   call the helpers instead of passing
                                style.type through unchanged
- round-trip.test.ts            +9 cases (4 renames × 2 directions
                                + 1 passthrough for custom types).
                                total 47, all green.

custom defs (folder / sheet / code-sandbox / widget / document)
pass through unchanged — soft-diamond moves to built-in, cutting
the planned customs from 6 → 5.
phase 3.1 — four small composable presentational components that
phase 3.2 custom node views layer together. each genuinely shared,
no stubs.

- NodeHeader      title bar with iconify emoji on the left, lock
                  indicator on the right. inline title editing opt-in
                  via onTitleEdit (double-click → input → Enter
                  commits, Esc cancels).
- NodeBreadcrumb  ancestry path, last segment non-interactive, others
                  optional click via onNavigate.
- NodeToolbar    compact icon-button row with radix tooltips. takes
                  a typed ToolbarAction[] so custom nodes assemble
                  their own actions.
- NodeFooter     status pill (idle / pending / saving / saved / error)
                  + relative timestamp + free-form left content.

styled with the existing shadcn + tailwind palette so they slot into
canvas-harness's DOM overlay without bespoke styling. no tests —
purely presentational, the behavior surfaces in phase 3.2.
phase 3.2 (1/5) — first custom node. ships together with the router
infrastructure since neither works without the other.

learning: NodeTypeDef.view in v0.0.5 is a stub. real mount happens
via <Canvas renderCustomNodeView={(id) => ReactNode}>. OverlayItem
(lib-internal) wraps each view at node.x/y/w/h. children passed to
OverlayItem are referentially stable across 'change' events, so each
view must subscribe per-id via useNode(id) to see live updates.

new structure:
  node-types/
    folder/
      def.ts          — defineNode({ drawPlaceholder, lod, hitTest })
                        no `view` field — that's unused in v0.0.5
      placeholder.ts  — canvas paint: folder body + tab, theme-coloured
      view.tsx        — ({ id }) → ReactNode; useNode(id) for live data;
                        iconify glyph + label; pointer-event-transparent
                        so canvas dispatches clicks
      index.ts
    render-view.tsx   — useRenderCustomNodeView() returns the
                        dispatcher fn for `<Canvas renderCustomNodeView>`
                        type → component registry
    index.ts          — boardNodeTypes[] for createBoardStore + router

migration plan §4.1 + §2.2 updated to match the actual lib contract.
no behavior wired yet — phase 4 mounts the canvas + folds these in.
phase 3.2 (2/5).

document is the first node where canvas node.type diverges from
Dim0 note.style.type. Dim0 documents are notes with type==="document"
but style.type is a regular NodeType (rectangle by default). canvas-
harness needs a distinct paint type, so noteToNode now overrides
node.type → "document" when note.type === "document".

to keep the round-trip lossless, NoteNodeData now carries the
original style.type as `styleType`. nodeToNote restores from it
before falling back to canvasTypeToDim0(node.type).

document custom node:
  def.ts          — defineNode({ type: "document", ... })
  placeholder.ts  — page outline + folded top-right corner
  view.tsx        — FilePdf / generic file icon + name + status pill
                    (pending / processing / completed / failed)

migration plan §4.1 contract followed: no view field in def,
useNode(id) inside the view, registered in render-view VIEW_REGISTRY
+ node-types boardNodeTypes[].

48 tests (47 + 1 new doc round-trip).
phase 3.2 (3/5).

widget renders node.content as an HTML/JS iframe inline on the
canvas — its body IS the inline view (unlike sheet / code-sandbox
which only show a preview). title bar at top stays
pointer-event-transparent so the canvas dispatches drags; the
iframe opts back in via pointer-events: auto so charts and
interactive widgets stay clickable.

sandbox: allow-scripts allow-popups allow-modals allow-forms.
no same-origin — iframe can't reach parent storage / cookies.

LOD: minZoomForReact 0.6, minZoomForPlaceholder 0.2. iframes
unmount below 0.6 since they're expensive; the placeholder draws
a framed card with a title-bar divider + faded grid pattern in
the content area so the type is still recognisable at low zoom.
phase 3.2 (4/5).

inline view shows title + language badge + preview of the first
~12 lines of code (monospace, no syntax highlighting at this LOD).
the full editor opens via the modal surface flow — phase 5 wires
that through board-app-store.activeNodeSurface.

placeholder: dark card with horizontal pseudo-code strokes of
varying widths suggesting indented lines. shape reads as code at
any zoom.

LOD: minZoomForReact 0.5, minZoomForPlaceholder 0.2.
phase 3.2 (5/5).

sheet inline view shows title + plain-text preview of the first ~8
lines of node.content. cheap stripMarkdown drops bold / italic /
code / link / heading marks so the preview reads cleanly without
mounting a markdown renderer (that lives in the modal surface,
phase 5). full TipTap editor opens via board-app-store.activeNodeSurface.

placeholder: lined-paper card with a title stripe + horizontal lines
suggesting body text. light cream background that flips to dark
stone for dark mode.

LOD: minZoomForReact 0.4, minZoomForPlaceholder 0.15 — sheets are
common, render at moderate zooms.

phase 3 done. five customs total (folder / document / widget /
code-sandbox / sheet) + router. all built-ins handled by lib paint.
ready for phase 4 (canvas mount + chrome).
phase 4.1 — lights on. <HarnessCanvas /> mounts the canvas-harness
store + canvas + minimap behind a localStorage feature flag so the
react-flow path stays the default. enable via devtools:

  localStorage.setItem("topix:feature.canvas-harness", "1")
  location.reload()

what HarnessCanvas does:
  - createBoardStore({ nodeTypes: boardNodeTypes }) (lazy via useRef)
  - reads boardId / rootId from useBoardAppStore (board-view mirrors
    scope from useGraphStore so both stores stay in sync while we're
    side-by-side)
  - hydrateBoardStore on scope change (now clears the scene first;
    cancelled-flag guard against late-arriving fetches)
  - useBoardDebouncedSave gated by a `ready` flag so hydration ops
    don't trigger spurious POSTs — the flag flips after hydration
    completes and the persistence effect re-baselines lastSavedRef
  - feeds useBoardTheme into <Canvas theme selectionColor background>
    + <Minimap *Color>
  - useRenderCustomNodeView dispatches sheet / widget / code-sandbox /
    document / folder to their views

tool state, keyboard shortcuts, top-bar rewire land in phase-4
follow-up commits. for now the canvas mounts in select mode only;
pan via middle-button or space+drag, zoom via cmd+scroll.
phase 4.2.

tool state moves into board-app-store (string field, default 'select')
so the top-bar can share it with the canvas. setBoardScope resets to
'select' on scope change. HarnessCanvas pulls it via the store and
passes to <Canvas tool={tool}>.

new useBoardKeyboard(store) hook:
  - Cmd/Ctrl+Z         → store.undo()
  - Cmd/Ctrl+Shift+Z   → store.redo()
  - Cmd/Ctrl+Y         → store.redo()
  - V                  → tool 'select'
  - H                  → tool 'pan'
  - F                  → tool 'frame'

skipped when focus is in input / textarea / contentEditable so inline
editing keeps the native shortcuts. canvas-harness already wires
Cmd+C/X/V/[/] internally — we don't override.
…tus)

phase 4.3.

three small floating components mounted alongside <Canvas>:

  HarnessToolbar         center-top tool tray.
                         select / pan | rect / ellipse / diamond |
                         arrow / text / frame. reads + writes `tool`
                         on board-app-store so keyboard shortcuts + UI
                         stay in sync.
  HarnessHistoryControls top-left undo / redo buttons. uses
                         useCanUndo / useCanRedo from the lib for
                         disabled state.
  HarnessSaveStatus      top-right pill. mirrors the use-debounced-save
                         status (idle / pending / saving / saved / error)
                         with idle rendering nothing.

intentionally scoped — full top-bar (add sheet / code-sandbox / widget /
share / chat / folder-depth indicator) reuse is deferred to a follow-up
sub-phase. this is enough chrome to drive the canvas + verify save / undo
in dev. existing data hydrates through the conversion layer; built-in
shapes drag-to-create via tool buttons; customs render from existing
board data.
phase 4.4. toolbar buttons now actually do something.

useCreateHandlers(store, boardId) routes <Canvas onCreateDrag /
onClick> through the Dim0 conversion layer:

  1. translate the canvas-harness tool name back to Dim0's NodeType
     (canvasTypeToDim0) — needed for createDefaultNote
  2. createDefaultNote so the node carries Dim0 default style +
     properties (font / colors / emoji slot / etc.) — required for
     round-trip persistence to land valid Notes on the server
  3. override nodePosition / nodeSize from the gesture (drag rect for
     onCreateDrag, world-click + default size for onClick)
  4. noteToNode + store.addNode

shape tools covered: rect / ellipse / diamond / tag / capsule /
thought-cloud / layered-rect / layered-ellipse / layered-diamond /
soft-diamond / text / frame. select / pan / arrow handled by the lib
internally; we no-op for those.

tool stays active after creation so the user can quickly drop
multiple of the same type — matches playground + tldraw / figma UX.
the migration plan calls for "full swap, no incremental cohabitation"
on this branch. the localStorage flag was extra ceremony — branch is
already the unit of work.

board-view now always mounts <HarnessCanvas />:
  - no flag check, no react-flow branch
  - scope mirrored unconditionally into useBoardAppStore
  - useGetBoard call kept as a compat shim so chat / dashboard /
    other components still reading from useGraphStore keep working
    until they're migrated (phase 7 deletes the legacy store path)

react-flow code (graph-editor.tsx and everything below) stays in
the repo but is unreachable from board-view. phase 7 deletes those
files in bulk along with the @xyflow/react dep.

no toggle needed any more — just open the app.
the root-layout renders a full-width page header at absolute top-0
z-50, height 64px. it visually contains only the sidebar trigger +
label on the left, but its DOM element extends full-width and
intercepts clicks across the entire top strip.

my chrome was at z-10 — visually showing through the empty header
space but unclickable. the existing top-bar uses z-50 to land in
the same stacking layer as the header; with equal z-index, later
DOM siblings win hit-testing, and my chrome is rendered after.

four spots bumped: toolbar / history-controls / save-status (top
strip) + minimap (bottom-right, less critical but consistent).
both canvas-harness and Dim0 use the 0-100 opacity scale. the lib's
resolveOpacity divides by 100 internally
(defaults.ts:67 — `return style.opacity / 100`).

dim0StyleToCanvas was pre-dividing by 100 — so a Dim0 default
opacity:100 reached the lib as 1, then the lib divided again to
0.01. shapes painted their fill at globalAlpha=0.01 (effectively
invisible) while the rough.js stroke pass — which sets no
globalAlpha (rough/draw.ts:192-195) — kept painting at alpha 1.

symptom on screen: colored rough outlines visible, fills missing.
visible fill returned when editing because the textarea overlay is
a DOM element with its own CSS background, immune to canvas alpha.

fix:
  - dim0StyleToCanvas       opacity: s.opacity (no divide)
  - dim0LinkStyleToCanvas   inherits via spread, fixed automatically
  - canvasStyleToDim0       opacity: s?.opacity ?? 100 (no * 100)
  - canvasEdgeStyleToDim0Link  same

round-trip test updated — node.style.opacity now equals the Dim0
value (80, not 0.8). migration plan §3.3 corrected — lib uses 0-100,
not 0-1.

48 tests still pass.
was top-left, but the top-left corner clashes visually with the
sidebar trigger sitting just above. bottom-left keeps it out of the
header zone and pairs nicely with the bottom-right minimap.
renderer.ts:340-360 dispatches custom nodes by checking
`if (def.view)`. truthy → add the id to the overlay set (which
triggers onOverlayChange → mountedIds → <Canvas renderCustomNodeView>
→ React view mount). falsy → fall through to def.renderCanvas (also
falsy here) → nothing painted.

migration plan §4.1 wrongly called def.view a v0.0.5 stub. it isn't —
the field is the live dispatch flag. without it, custom nodes
disappear at idle and only appear during pan (which forces the
preferCanvas path to drawPlaceholder).

each def now imports its view and sets `view: <View>`. the lib only
checks truthiness; the actual React mount goes through
renderCustomNodeView + VIEW_REGISTRY, so def.view is purely a flag.

affects folder / document / widget / code-sandbox / sheet.
canvas-harness applies autoFit on resize-commit
(use-interaction-gesture.ts:202-218): for selected nodes with
shouldAutoFit, it measures node.content laid out at node.w and
forces node.h up if content needs more height. grow-only — so
user-shrunk heights snap back on pointerup.

for built-in shapes that's the right UX (sticky / shape grows to
fit user-typed text). but our customs carry the FULL body markdown
in node.content (sheet text, code, widget html source) while the
React view only renders a preview — autoFit would snap the box
tall enough for the whole body, breaking height resize.

set style.autoFit = false at the convert boundary for folder /
sheet / code-sandbox / widget / document. built-in shapes keep
autoFit on. AUTOFIT_DISABLED_TYPES set + one-line override in
noteToNode.

symptom this fixes: width resize works, height resize bounces back
to content height on release.
flex columns with overflow-auto on the body don't engage scroll
because flex children default to min-height: auto, which keeps the
item from shrinking below its content's intrinsic height. without
min-h-0 the body grows past its allotted slot and overflow-auto
never fires.

switching to absolute-positioned inner shell sidesteps any
percentage-height edge cases on top of that:

  <div className="relative h-full w-full pointer-events-none">
    <div className="absolute inset-0 flex flex-col overflow-hidden ...">
      <div className="shrink-0 ...">{title}</div>
      <div className="min-h-0 flex-1 overflow-auto scrollbar-thin
                      pointer-events-auto">
        {body}
      </div>
    </div>
  </div>

outer: positioning context, drag passes through (pointer-events-none).
inner: fills outer by absolute inset-0 — robust against any other
percentage-height quirks.
title: shrink-0, inherits pointer-events-none so canvas drag works
       on the title bar.
body: flex-1 + min-h-0 + overflow-auto enables scroll;
      pointer-events-auto so the wheel scrolls instead of zooming.

also drops the previously-applied line-clamp on the preview <p> in
sheet so the scrolling body can grow naturally.

folder / document / widget unchanged: folder and document have no
scrollable body content; widget's iframe already does its own scroll.

pairs with the autoFit-disable fix in the previous commit — that
lets height-resize actually stick, this lets content scroll inside
the resized box.
backend rejects canvas-harness's default `${clientId}-${counter}` ids
with "value u-99e8-1 is not a valid point ID, valid values are either
an unsigned integer or a UUID". server validates link.source / .target
against a UUID/int regex.

createCanvasStore takes an `idGenerator` option. wiring Dim0's existing
generateUuid (uuidv4 with dashes stripped — what every existing note in
the DB uses) makes every lib-generated id (arrow tool edges, addImage /
addSvg, copy-paste-created ids, drag-to-create shapes) acceptable to
the existing endpoints.

no collision risk — uuids are globally unique, same guarantee as the
clientId-embedded scheme. phase 6 collab is unaffected; the lib only
needs uniqueness, not clientId-embedded ids.
reproduces dim0 prod behavior on the canvas-harness path. localStorage
entries are read by board-app-store.setBoardScope on scope change; this
commit threads them through useBoardTheme into <Canvas background>.

new css-vars.ts — readCssVar(name) helper: resolves a CSS custom
property from :root at call time. used for texture colors so they
track the theme variant exactly.

background.ts rewritten with the dim0 logic:
  - boardBackground is `null` → fall back to swatch[0]
                                (step 2 swaps this to readCssVar('--background')
                                 for an exact match)
  - boardBackground set       → applyBackgroundAlpha(
                                  darkModeDisplayHex(c, isDark) ?? c,
                                  0.5,
                                ) — same 50%-alpha tint behavior
  - texture "dots"  → pattern: "dots",  patternColor: --muted-foreground
  - texture "lines" → pattern: "grid",  patternColor: --muted
                      ("lines" is dim0's name; "grid" is canvas-harness's,
                       same line-grid pattern semantically)
  - gap: 25, minZoom: 0.4 (matches dim0's react-flow Background props)

use-board-theme.ts subscribes to boardBackground +
boardBackgroundTexture from board-app-store; memo dep list grows so
changes trigger renderer.setBackground.

helpers reused from existing webui code:
  - applyBackgroundAlpha       (board/utils/board-background.ts)
  - darkModeDisplayHex         (board/lib/colors/dark-variants.ts)
  - BoardBackgroundTexture type

step 1 of 2. step 2: swap the swatch[0] fallback for
readCssVar('--background') so the default page bg matches the rest of
the app pixel-for-pixel.
ports dim0's bottom-left ViewportControls panel onto the harness path
and reduces the dot / line pattern contrast against the page bg.

new chrome/viewport-controls.tsx (bottom-left, replaces the prior
HarnessHistoryControls):
  - zoom % button (click resets camera.z to 1)
  - undo / redo (via store.undo / store.redo, gated by useCanUndo /
    useCanRedo, disabled visual state)
  - background popover trigger — shows current tinted preview
  - popover content mirrors dim0:
      • color grid: white + TAILWIND_50 palette (dark-mode-shifted
        for visibility against dark themes via darkModeDisplayHex)
      • texture row: None / Lines / Dots with icons (GridFour,
        DotsNine), highlights current selection
      • Reset buttons per section

texture color was reading --muted-foreground / --muted at full
opacity. canvas-harness paints dots / lines more boldly than
react-flow's SVG pattern, so they came out too contrasty. now via
new readCssVarMixed(name, percent) helper in css-vars.ts — uses a
hidden DOM probe so the browser does the color-mix natively (works
for oklch / rgb / hex source vars). dots @ 25%, lines @ 35%.

history-controls.tsx removed — undo / redo absorbed into the
viewport-controls panel to match dim0's single-row layout.
step 2 — default page bg now matches the rest of the app exactly,
not the swatch approximation. user-picked tints pre-blend against
--background via color-mix so they no longer mix with the renderer's
hardcoded wrap-div bg.

background.ts changes:
  defaultBg = readCssVar("--background") || swatchBg

  tinted = boardBackground
    ? blendCssColors(
        darkAwareUserColor,
        "var(--background)",
        50,                     // 50% user + 50% --background
      )
    : null

  // canvas-harness paints a SOLID color; using rgba(..., 0.5) would
  // bleed the wrap-div's hardcoded `#f8fafc`. pre-blending in JS gives
  // the same visual result as dim0's CSS alpha overlay on bg-background.

new helper in css-vars.ts:
  blendCssColors(a, b, aPercent) — generic color-mix wrapper. uses a
  hidden DOM probe so the browser resolves any CSS color format
  (oklch, rgb, hex, var(--*)). returns a solid resolved value.

minimap colors unchanged here (still swatch-derived). swap to CSS
vars is a follow-up if the minimap bg ends up looking out of sync.
restore the last viewport on board mount; save on every gesture-end.
no per-frame work, no visibilitychange listener, no cleanup save — a
single 'interaction' subscription captures everything that matters,
because the camera doesn't move between gestures.

viewport-storage.ts
  same localStorage key + shape as dim0's react-flow path
  (topix:graph-viewports, Record<scopeKey, { x, y, zoom }>) so saved
  viewports survive the migration. canvas-harness CameraState.z
  translates to/from `zoom` at the boundary.
  scopeKey = `${boardId}:${rootId ?? ""}` (matches dim0 exactly).

use-viewport-persistence.ts
  restore: one-shot after the `ready` flag flips (post-hydration).
           store.setCamera(saved) — synchronous, no tween.
  save:    subscribe('interaction'). on every panning|zooming → idle
           transition, write store.getCamera() to the scope key.
           tracks previous mode in a useRef to detect the edge.

wired into HarnessCanvas alongside useBoardDebouncedSave +
useBoardKeyboard. zero impact on pan/zoom hot path — the
'interaction' callback fires on mode changes only (≈ 2 events per
gesture, not 60 per second).
ports dim0's rail-style StylePanel onto the harness path. mounted
mid-left as a floating sidebar that shows when one or more
non-frame nodes are selected, hides otherwise.

  chrome/style-panel/
    key-swatch.tsx   small color swatch button. direct port —
                     dark-mode-aware via darkModeDisplayHex,
                     checker pattern for the transparent entry.
    color-panel.tsx  tailwind-palette ColorGrid. direct port —
                     left-click picks family, right-click opens
                     shade grid (compact variant).
    panel.tsx        rail-style StylePanel adapted to canvas-harness
                     Style. 10 settings: border / background / text
                     color (ColorGrid), stroke width / style, sloppiness,
                     roundness, text align, font family / size. each
                     row is a popover trigger opening on the right.
                     skips fillStyle (dropped in convert layer) and
                     textStyle (canvas-harness lacks underline /
                     strikethrough — partial mapping not worth the
                     conversion cost here).
    sidebar.tsx      reads useSelection() + useNodes(), derives the
                     first selected non-frame node's style as the
                     representative shown in the rail, dispatches
                     style patches to ALL selected via store.batch
                     for atomic undo.
    index.ts

edges are deferred (pathStyle / arrowheads → v2 follow-up).
style memory (last-used → applied to new shapes via useStyleDefaults)
also deferred — small but needs threading through the create
handlers in use-create-handlers.ts.

positioned left-3 top-1/2 (mid-left) so it doesn't collide with the
viewport-controls panel at bottom-left or the toolbar at top-center.
popovers open to the RIGHT (inward) so they don't clip off-screen.
phase 4.5 — custom node creation from the UI.

toolbar.tsx grows a 4th group at the end with the four custom types.
icons (phosphor): Folder, Notepad, CodeBlock, Browser.

use-create-handlers.ts adds the four type strings to SHAPE_TOOLS so
they route through the same drag-to-create / click-to-create path as
built-in shapes:

  isShapeTool(tool) → canvasTypeToDim0(tool) → createDefaultNote
    → noteToNode → store.addNode

createDefaultNote handles each type's default size (sheet 320×200,
code-sandbox 320×320, widget 800×500, folder 150×150) and default
style. tool stays active after creation so the user can drop multiple
in a row, same as shapes (V to switch back to select).

document tool deferred — that's a file-upload flow, not a click-to-
create. lands in phase 5 alongside drag-drop image upload.
winlp4ever added 21 commits May 24, 2026 16:44
Three coupled fixes so sheets behave like prod's nested-route
surfaces:

1. Surface ↔ URL sync — openNodeSurface / closeNodeSurface now
   push /boards/$id/sheets/$noteId via a setNodeSurfaceNavigator
   callback registered by the new useHarnessSurfaceFromUrl hook.
   Browser back closes surfaces; refreshing on a sheet URL re-opens
   it; shareable links work.

2. Local-or-remote sheet read — SheetPanel falls back to useGetNote
   when the node isn't on the current canvas (subpages reached via
   the editor's /subpage slash command). Writes branch on
   isLocalNote: store.updateNode for canvas-resident sheets,
   updateNote REST mutation for off-canvas ones. boardId resolves
   from note.graphUid so per-note API calls hit the right graph
   even for cross-subgraph subpages.

3. Sheet stack background — wires the existing SheetStackBackground
   ghost-card layers behind nested sheets so depth reads visually,
   matching prod.
Three coupled additions plus the long-missing image-render fix:

1. Drag-drop image upload — onDragOver/onDrop on the canvas wrap
   downscales, uploads, and adds image nodes at the world-space drop
   point (centered, slight stagger when multi-dropping). Images
   created inside a sub-folder carry parent_id via the same rootId
   pipeline used for shape creation.

2. Image-search dialog — port of prod's ImageSearchDialog wired into
   a new "More" overflow dropdown at the right of the toolbar. Both
   search results and the in-dialog file picker route through the
   shared useHarnessAddImage hook, dropping at the current viewport
   center via a new wrap-ref context.

3. ?center=<nodeId> URL param — snaps camera to center the target
   node after hydration, then strips the param so a later refresh
   doesn't re-snap.

4. Image render fix in noteToNode — canvas-harness's paintImageNode
   reads node.data.src / naturalW / naturalH, but the convert layer
   was only stashing the URL inside data.properties.imageUrl. Image
   nodes (new + pre-existing) rendered as empty boxes. Lifting the
   url + dims onto the data primitives so they actually paint.
Adds an optional `is_local_offset: bool = False` to PositionProperty.
When set on a link's start_point / end_point and the link's
source/target resolves to an attached node, the position is to be
interpreted as a node-local offset (relative to the node's top-left,
pre-rotation) instead of an absolute world coordinate.

Default False keeps the legacy world-coord interpretation for
existing rows, so no data migration is needed — edges upgrade in
place on their next save. Unblocks the harness frontend from
having to cascade updateLink calls every time an attached node
moves.
Edges now save `start_point` / `end_point` as local offsets relative
to the attached node's top-left when the source/target resolves to
a node, with `is_local_offset: true` on the wire. This matches
canvas-harness's in-memory model and removes the need to resave an
edge every time its attached node moves — the offset is invariant
under node geometry changes, only the rendering reads node.x/y.

Legacy edges in the old world-coord format are detected on load
(no `is_local_offset` flag) and stashed with a per-end marker on
edge.data. The persist diff cascades an `updateLink` for those
legacy edges only when their attached node's geometry changes,
which upgrades them in place. Newly-saved edges + freshly-upgraded
edges don't need the cascade.

Drops the `isCenter` shortcut that omitted `position` for
center-attached endpoints; always emitting `position` kills the
PATCH-strip risk when an endpoint went from off-center back to
center. Pairs with backend commit bc3bedd which added the
`is_local_offset` field to PositionProperty.
Canvas-harness stores `edge.control` as the cubic Bezier control
points `[c1, c2]`; the wire format `edgeControlPoint.position` is
the single midpoint the curve passes through at t=0.5 (matches
prod's convention). We were saving `c1` into that field directly,
so dragging the edge midpoint never round-tripped through refresh.

Save now resolves source/target to world coords and inverts the
lib's symmetric split (M = (S + T + 6·c) / 8). Load uses the lib's
own `midpointToCubicControls` to derive `[c1, c2]` from the saved
midpoint. Setback we accept (matches excalidraw): moving an
attached node leaves the midpoint at its stored world coord until
the user re-drags it — adding "midpoint follows nodes" is its own
scope.
New shapes and edges inherit the user's last-used style — change a
rect's background to red, the next ellipse / diamond / arrow also
comes out red. Persisted to localStorage so preferences survive
reloads.

Driven by canvas-harness store events: every `node.update` or
`edge.update` op carrying a `style` patch folds the resolved style
into a shared bucket (excalidraw / tldraw / figma model). Custom
node types (folder, sheet, code-sandbox, widget, document) are
excluded from both input and output so they keep their built-in
visual identity. Frames also opt out of the merge to stay generic.

Arrow tool picks up edge memory via the lib's `arrowDefaults` prop;
create handlers merge node memory after `noteToNode` so the convert
layer stays untouched.
Phase 6.0 — origin-aware persist. The debounced save loop now
branches on `batch.origin`: remote batches (agent writes via
`store.applyBatch({ origin: 'remote' })`) rebaseline lastSaved and
skip the timer so we don't re-upload data the server already has.
Local and history batches continue to flow through the normal diff.

Phase 6.1 — harness/agent module. `apply-tool-output.ts` exposes
`applyNoteOutput` and `applyLinkOutput`: fetch canonical Note/Link
from the server, convert via the harness convert layer, apply as a
single remote-origin op batch. Notes outside the current rootId
skip canvas materialization but still update the per-note query
cache so an open subpage panel sees the edit. `agent-bridge.ts`
provides `setAgentBridge` / `getAgentBridge` (mirrors the existing
setBoardNavigate / setNodeSurfaceNavigator pattern) so the agent's
`sendMessage` mutation can reach the harness store without
importing the canvas component tree.

Phase 6.2 — send-message rewire. After the legacy graph-store
apply block, `useSendMessage` now also mirrors note/link tool
outputs into the harness store via the bridge. Switches the
post-stream camera nav from `?center_around=` to our existing
`?center=` URL param, extended in `use-center-from-url.ts` to
accept comma-separated ids (fits the union bounding rect with
10% padding, zoom capped at 2).

`board-view.tsx` mounts `<FloatingAssistant>` and `<CopilotSheet>`
on the harness board surface, threading `current_chat_id` from the
URL search params and `chatSheetOpen` from the legacy store as a
compat shim. Both stays in sync until phase 7 deletes the legacy
store.
Switches four legacy useGraphStore reads onto the harness
useBoardAppStore so the agent UI is driven by the store our own
actions write to, not the URL-synced compat shim:

- floating-island.tsx + chat/input.tsx — `hasActiveSurface` (drives
  the @page / @board chip)
- use-submit-prompt.ts — `rootId` baked into the send-message
  payload so agent writes land in the right folder scope
- save-as-note.tsx — `rootId` baked into the post-save navigate

Trims one URL→legacy-store hop and shrinks the surface that
phase 7 will need to remove when the legacy graph-store goes away.
The agent's message-context builder now sees what the user has
selected on the canvas-harness canvas. Before this, selection lived
only in the harness store (legacy graph-store wasn't synced) so the
floating-island composer's "selected" chip never lit up and
prompts like "summarize what I selected" had no context to attach.

New `harness/canvas-store-ref.ts` exposes a module-level
`set/getCanvasStoreRef` bridge (same shape as setBoardNavigate /
setNodeSurfaceNavigator / setAgentBridge). HarnessCanvas registers
the active store in a useEffect. `useHasMessageContext` subscribes
to the lib's 'selection' event channel via the bridge through
useSyncExternalStore — works without a CanvasProvider ancestor, so
the floating-island composer (which lives as a sibling of
HarnessCanvas) can read selection without restructuring the tree.
`buildMessageContext` resolves notes via store.getNode → nodeToNote
with a React-Query-cache fallback for off-canvas subpages.

Z-index shortcuts (Cmd+] / Cmd+[ / Cmd+Shift+] / Cmd+Shift+[) ship
for free — canvas-harness's Canvas component already binds them
internally with INPUT/TEXTAREA focus guards. No new code needed.
Right-click on a selection now opens a context menu with four
sections, matching prod's behavior on the canvas-harness branch:

- Position: send backward / forward / to back / to front via the
  lib's reorder methods (Cmd+[ / Cmd+] continue to work alongside).
- Export: copy selected as PNG (clipboard-first, file-download
  fallback) with a transparent-background toggle, plus an SVG
  download bonus — both via the lib's exportSelection /
  exportSelectionSvg helpers.
- AI Spark: summarize / mapify / schemify / quizify / drawify /
  explain — six actions dispatched through the existing
  useAiSparkActions hook.
- Translate: expandable submenu with a custom-language input + the
  eight common languages from prod.

Backing the AI Spark + Translate flows: a new
useHarnessApplyMindMap hook in harness/agent/. It subscribes to
useMindMapStore — the staging buffer the existing API hooks (
convertToMindMap / drawify / translateText) write into — and drains
new mindmaps into the canvas-harness store as a local-origin op
batch. The normal debounced-save loop then POSTs them. Replaces
the unmounted react-flow drainer (useAddMindMapToBoard) that
silently no-op'd on the harness. Skips point-node materialization;
edges attach natively via EdgeEnd.localOffset at node center.
The right-click menu was bottom-heavy with the 6 AI Spark actions
plus Translate expanded inline. Fold both under a single "AI ▶"
click-to-expand entry — Position and Export stay inline since
they're more frequently used and self-contained. The collapsed
state shows just three top-level groups; expanding AI reveals the
six actions plus the existing Translate submenu nested with a
left-border indent for hierarchy.
Port prod's IconSearchDialog and refactor the harness toolbar to match
prod's styling, icon set, full shapes menu, and keyboard map.
Adapt node + edge colors for dark mode at render time without
persisting the adapted values. Stored colors round-trip on
data._storedColors; node.style holds the displayed projection.
Theme toggles re-project via a single remote-origin batch — no
save, one redraw.
Panel now supports edges (stroke + text color, stroke width / style,
sloppiness, path style, source + target arrowheads, font family + size).
Sidebar splits node vs edge selection (nodes win on mixed) and routes
edge-only fields correctly (pathStyle on Edge, not Edge.style). New
edges drawn by the arrow tool stamp _storedColors + project for the
current theme mode so dark-mode paints stay coherent.
Fire once per board scope on hydrate, scheduled via
requestIdleCallback (RAF fallback for Safari). Paints to an offscreen
canvas with the lib's renderMinimapContent — same code path the live
minimap uses — and uploads through the existing saveThumbnail API.
Skips empty boards; failures are best-effort.
Replace the toolbar Frame button with a Slides button that opens a
right-side panel listing the board's frames as draggable slides.
Reorder calls store.setFrameOrder (one undoable, collab-syncing op);
Add Slide creates a 960x540 frame at the viewport center; Present
enters presentation mode anchored on the first frame.

Presentation mode hides frame chrome via Renderer.setHideFrames
before the camera moves (no flash) and restores both on exit.
Arrow keys step slides, Esc exits, M toggles the panel.
Sheet preview now renders rich markdown via MarkdownView (Streamdown
+ GFM + KaTeX + Shiki) capped at 800 chars, gated by useIsInView so
off-screen sheets at high zoom don't re-parse. Folder drops the
iconify glyph for an inline SVG silhouette matching the canvas
placeholder — same look low- and high-zoom. New NodeDragHandle gives
sheet / code-sandbox / widget a grab target that bypasses the body's
click-to-open and forwards pointer events to canvas-harness for
select / drag.
WidgetIframe now serves its content through a Blob URL src instead of
srcDoc — Chromium's srcDoc navigation can silently fail inside a
CSS-transformed ancestor (canvas-harness applies a camera transform
to the overlay). Dropped the unmount cleanup that set src to
about:blank: under React's mount/unmount churn on canvas motion, the
cleanup could land on the live DOM node and never get re-navigated
back, leaving panned widgets stuck at about:blank.

Custom-node title captions (sheet / folder / widget / code-sandbox /
document) now use font-handwriting (Architects Daughter) for a
hand-drawn feel that fits the board surface.
Pulls the upstream z-index fix for sendToBack into the renderer.

Splits two files that were mixing component + non-component exports
so eslint's react-refresh/only-export-components stops flagging them:
wrap-ref-context.tsx → context+hook (.ts) and provider (.tsx);
edge-glyphs.tsx → glyph components, with constants + types moved to
edge-glyph-options.ts.
…zoom

FolderSilhouette SVG was using hsl(var(--card)) which is invalid —
--card resolves to oklch(), not raw HSL components. Fell back to
SVG's default black fill in idle. Switched to var(--card) so the
silhouette renders card-colored in both idle and motion states.

Lowered minZoomForPlaceholder to 0.05 on all custom node defs
(folder / sheet / widget / code-sandbox / document) so they stay
visible at deep zoom-out — matches built-in primitive behavior. Lib's
prior floor was a perf knob for >1000-node boards; we don't run at
that scale.
@winlp4ever winlp4ever force-pushed the feat/canvas-harness branch from 4565a59 to 32e3da2 Compare May 25, 2026 11:25
Re-enables the Document entry in the More dropdown. New DocumentUploadDialog
posts a PDF through the existing parseDocument API, then converts the
returned Notes + Links via noteToNode / linkToEdge and applies them as a
single remote-origin batch (server already saved them; save loop skips it).
Toast surfaces parse progress + result; dialog closes immediately so a long
parse doesn't block the canvas.

Sheet body switches from <button> to role="button" div. MarkdownView's code
blocks render their own Streamdown download/copy buttons, and nested
<button> was triggering a hydration error. Keyboard handlers preserve
Enter/Space activation.

Move WidgetIframe Blob URL allocation inside useEffect so the create +
revoke live in the same cleanup cycle. With useMemo + a separate effect,
StrictMode's fake unmount/remount revoked the URL while the iframe was
still fetching it, surfacing "Not allowed to load local resource: blob:..."
in Chromium.
Adds the view-mode dropdown to the toolbar (Board / Files / List)
and ships both alternate surfaces:

- ListView: OS-finder-style row index of document-like nodes (sheet,
  widget, code-sandbox, document, folder). Folder click navigates to
  the subboard; everything else opens the surface modal. Sorted by
  listOrder.
- LinearView (Files): responsive grid that reuses the canvas custom
  React views inside each cell. Drag-handle reorder via store.batch()
  of listOrder updates. One source of truth for each type's look.
- EmbeddedNodeViewProvider context suppresses the inner NodeDragHandle
  when reusing canvas views inside cards (avoids double grips).
- Canvas-only toolbar tools hide in non-board modes.
- Sheet off-screen body renders a dimmed "Sheet paused" placeholder
  instead of nothing (matches widget's pattern).

Tested with the existing harness suite (55/55); type-check + lint clean.
…odel

GraphStore.patch_note was always validating the merged payload against
the bare Note model, which has type: Literal["note"]. Document rows
(Note subclass with type: Literal["document"]) failed validation on
any update — surfaces as soon as you reorder a document in Files view,
or anything else that PATCHes a document node.

Switch to Document.model_validate when the existing row is a Document,
keep Note.model_validate otherwise. Document-specific properties
(mimeType, status, summary) survive round-trip.

Client side: drop the temporary type="note" workaround in the persist
flush, just strip immutable fields (id, graphUid, createdAt) and let
the real type ride along.
Read-only counterpart to TipTap's PageRefView. MarkdownView (Streamdown)
now turns `[Title](page://<id>)` links into a small @-icon chip via a
short-circuit in MarkdownLink. Subpage directive blocks
(`:::page <id>\nTitle\n:::`) collapse to inline page-ref links via a
pre-remark string preprocessor, so subpages render as chips too instead
of raw `:::page` text.

Snapshot title is rendered as-is — no live PageProvider lookup. Suitable
for read-only contexts (sheet card previews, agent messages, newsfeed
view).
Harness is the sole canvas renderer. This drops the entire
react-flow-based code path it ran in parallel with:

graph-store migration
- Move chatSheetOpen onto board-app-store; everything else shell-side
  (board-screen, board-view, root-layout, create-board) reads scope
  from board-app-store directly.
- sidebar-label reads boardCanEdit / boardLabel from board-app-store
  and the live sheet title via a small canvas-store-ref adapter
  (use-harness-node-external).
- Strip the duplicate legacy block in send-message that mirrored each
  AI tool output into graph-store (harness bridge already does it).
- Drop useGetBoard / useParseDocument React hooks (bare API fns
  remain — harness consumes them directly).

Deletions
- store/graph-store.ts and every component / hook / util tied to it:
  graph-editor + graph-canvas + node-view + note-card + the linear /
  list views, slide-panel + presentation-controls, sheet / widget /
  code-sandbox panels, action-panel + top-bar, edge + shapes
  sub-trees, folder-breadcrumb (restored a board-app-store version),
  motion-state-context, the legacy style panel, every retired hook
  (use-add-node, use-thumbnail-capture, use-make-thumbnail,
  use-board-shortcuts, use-place-line, use-copy-paste,
  use-center-around, use-edge-label-edit, use-node-viewport-cull,
  use-content-min-height, use-add-image-from-file,
  use-drop-image-upload, use-fit-nodes, use-decorated-edges,
  use-export-selection-png) and orphaned utils
  (point-attach, line-placement, flow-view, edge-node-geometry,
  edge-orientation, edge-label-estimate, shape-content-scale,
  thumbnail, markdown-height-estimate, note-box) plus the rough/*
  SVG variants and the components/notes/* shapes that only the
  legacy view rendered.

Net: 107 files touched, ~18.7k LOC deleted. type-check + lint clean;
harness round-trip suite (55 tests) green.
Brings in upstream canvas markdown math rendering + minor renderer
perf tweaks. No webui-side changes needed.
Adds a short "Canvas engine" section linking to the canvas-harness
library and noting the board scales to thousands of nodes, comparable
to tldraw / Excalidraw / hosted whiteboards.
@winlp4ever winlp4ever merged commit 695fff7 into main May 25, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant